Содержание¶
Для навигации по содержанию необходимо кликнуть на интересующий раздел. Чтобы вернуться в содержание после клика на раздел, можно кликнуть снова на название раздела.
Проект выполнили: Ткалич Леонид, Шамшединов Илья, Шумилин Андрей, Шурыгин Всеволод.
Задание¶
Введение- В рамках проекта №2 требуется реализовать автоматизированный пайплайн для прогнозирования значений временного ряда на следующий день.¶
- Ряды представляют из себя срезы в обозначенные моменты времени притоков, оттоков и сальдо показателя, связанного с потоками ликвидности Банка
- Прогноз требуется строить для значения сальдо (разнинцы между притоками и оттоками)
- Заказчик модели высказал пожелание, чтобы ошибка прогноза составляла не более 0.42 в абсолютном значении
Требования к модели:¶
- Выбор оптимизируемой метрики должен быть основан на потребностях бизнеса
- Модель может использовать внешние факторы (см подсказки)
- Модель должна иметь модуль отбора признаков. Метод отбора должен быть болеее стабильным относительно альтернатив. Сравнение должно быть проведено минимум с одним методом из каждой категории: встроенные, оберточные и фильтрационные. При этом как минимум одна из альтернатив должна исследовать нелинейную зависимость.
- Модель должна автоматически подбирать гиперпараметры, оптимизируя целевую метрику
- Для модели должна быть подобрана частота калибровки, если модель калибруется долго, и проверена ее достаточность
- Блоки должны быть подписаны и кратко описаны (чем руководствовались при реализации, как работает)
- Модель должна автоматически дообучаться. Все модули должны работать без ручных корректировок. Выбор периода для дообучения должен быть обоснован.
- В модели должен быть модуль выявления разладки для подачи сигнала о возможной необходимости переключения на на ручное управление процессом/внеплановое дообучение
Подсказки:- Можно использовать факторы, сконструированные из таргета (лаги, средние и т.п.)¶
- Могут помочь макроэкономические факторы
- Могут помочь даты налоговых дней
Описание бизнес-процесса:¶
- Прогнозная величина позволяет установить сальдо поступлений и списаний за день.
- На основании прогноза позиционер (управляет ликвидностью) принимает решение о выделении средств на размещение на рынке деривативов для получения дополнительной маржи (доходность считать примерно ключ+0.5%)
- В случае, если на конец дня образуется профицит ликвидности, его можно разместить в ЦБ по overnight ставке, равной ключу-0.9%
- В случае, если на конец дня образуется дефицит ликвидности, его можно покрыть за счет займа по overnight ставке, равной ключ+1%
Источники¶
- Временной ряд от заказчика
КонсультантПлюс- Даты выплаты налогов в разрезе по типам
- Выходные и нерабочие дни
Сайт Банка России- Инфляция
- Ставка (RUONIA)
- Инфляция
- Курс доллара
Сайт Московской Биржи- индекс МосБиржи
ts_filepath = 'data/project_2.csv'
holidays_filepath = 'data/holidays.pickle'
taxes_filepath = 'data/taxes.csv'
rate_filepath = 'data/rounia.xlsx'
inflation_filepath = 'data/inflation_and_cb_rate.xlsx'
currency_path = 'data/usd.csv'
moex_path = 'data/IMOEX.csv'
%load_ext autoreload
%autoreload 2
import warnings
import requests
import os
import json
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import pickle
import plotly.graph_objects as go
from matplotlib.ticker import AutoMinorLocator
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.tsa.stattools import adfuller, kpss
from statsmodels.tsa.stattools import acf, pacf
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from bs4 import BeautifulSoup
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', 150)
from src.models import BaselineModel # Наша модель
from src.model_evaluation.metrics import TargetLoss, MaxAE, MAE, NumCriticalErrors, MoneyLoss
data = pd.read_csv(ts_filepath)
data.columns = [col.lower() for col in data]
data['date'] = pd.to_datetime(data['date'])
for col in data.columns[1:]:
data[col] = data[col].astype(str).str.replace(',', '.').astype(float)
data = data.set_index('date')
print(data.shape)
display(data.describe().round(3))
data.head()
(1543, 3)
| income | outcome | balance | |
|---|---|---|---|
| count | 1543.000 | 1543.000 | 1543.000 |
| mean | 1.085 | 1.134 | -0.049 |
| std | 0.839 | 0.902 | 0.292 |
| min | 0.000 | 0.000 | -2.510 |
| 25% | 0.000 | 0.000 | -0.143 |
| 50% | 1.330 | 1.330 | 0.000 |
| 75% | 1.670 | 1.740 | 0.038 |
| max | 5.110 | 5.000 | 1.410 |
| income | outcome | balance | |
|---|---|---|---|
| date | |||
| 2017-01-09 | 1.340000 | 1.490000 | -0.155904 |
| 2017-01-10 | 1.068610 | 1.194182 | -0.125572 |
| 2017-01-11 | 0.944429 | 0.936663 | 0.007767 |
| 2017-01-12 | 1.670000 | 0.875379 | 0.800391 |
| 2017-01-13 | 0.955924 | 0.975645 | -0.019721 |
data.isnull().sum()
income 0 outcome 0 balance 0 dtype: int64
all_dates = pd.DataFrame({
'date': pd.date_range(data.index.min(), data.index.max(), freq='D')
})
all_data = pd.merge(
left=all_dates,
right=data.reset_index(),
on=['date'],
how='left'
).set_index('date')
all_data.isnull().sum()
income 0 outcome 0 balance 0 dtype: int64
data.index.min(), data.index.max()
(Timestamp('2017-01-09 00:00:00'), Timestamp('2021-03-31 00:00:00'))
Отлично, в данных нет пропусков и присутствует информация за каждый день с 09.01.2017 по 31.03.2021
Посмотрим как выглядит ряд далее после загрузки остальных данных
def _parse_links_with_calendars_from_tax_page(url: str):
r = requests.get(url)
soup = BeautifulSoup(r.text, 'html.parser')
div = soup.find('div', class_='document-page__toc').find('ul')
link_to_calendar = 'https://www.consultant.ru' + div.find(lambda tag: 'Часть 3. Тематический Календарь' in tag.text and tag.name == 'a').attrs['href']
calendar_page = BeautifulSoup(requests.get(link_to_calendar).text, 'html.parser')
tax_types_info = [c for c in calendar_page.find('div', 'document-page__toc').contents if c.text.strip(' \n')][0]
links = ['https://www.consultant.ru/' + item.attrs['href'] for item in tax_types_info.findAll('a')]
return links
def _parse_tax_dates_from_tax_calendar_page(url: str):
r = requests.get(url)
soup = BeautifulSoup(r.text)
columns = soup.find('div', class_='doc-table').findAll('td')
tax_type = columns[0].text.replace('\n', '')
tax_result = {
tax_type: {}
}
tax_subtype = ''
prev_paragraph_has_dates = True
for paragraph in columns[1].findAll('p'):
if paragraph.text:
all_links = paragraph.findAll('a')
has_dates = len(all_links) > 1
if has_dates:
dates_to_add = [
item.text for item in all_links
if all([letter.isdigit() for letter in item.text.replace('.', '')])
]
dates_to_add = [
item for item in dates_to_add if item
]
if dates_to_add:
if not tax_subtype:
tax_subtype = 'no_type'
if tax_subtype in tax_result:
tax_result[tax_type][tax_subtype] += dates_to_add
else:
tax_result[tax_type][tax_subtype] = dates_to_add
else:
if prev_paragraph_has_dates:
tax_subtype = ''
tax_subtype += paragraph.text + ' '
prev_paragraph_has_dates = False
return tax_result
if os.path.exists(taxes_filepath):
taxes = pd.read_csv(taxes_filepath)
else:
dicts = []
urls = {
2017: 'https://www.consultant.ru/document/cons_doc_LAW_208577/',
2018: 'https://www.consultant.ru/document/cons_doc_LAW_284538/',
2019: 'https://www.consultant.ru/document/cons_doc_LAW_312984/',
2020: 'https://www.consultant.ru/document/cons_doc_LAW_339977/',
2021: 'https://www.consultant.ru/document/cons_doc_LAW_371805/'
}
taxes = []
for year, url in urls.items():
links = _parse_links_with_calendars_from_tax_page(url)
cur_taxes = {}
for link in links:
cur_taxes |= _parse_tax_dates_from_tax_calendar_page(link)
year_taxes = []
for tax_type, values in cur_taxes.items():
for tax_subtype, tax_dates in values.items():
cur_df = pd.DataFrame(tax_dates)
cur_df.columns = ['date']
# Ошибка в К+ на 2020 годе
cur_df['date'] = cur_df['date'].str.replace('20.20', '20.10')
cur_df['date'] = pd.to_datetime(
cur_df['date'] + f'.{year}', format='%d.%m.%Y'
)
cur_df['tax_type'] = tax_type
cur_df['tax_subtype'] = tax_subtype
year_taxes.append(cur_df)
year_taxes = pd.concat(year_taxes)
taxes.append(year_taxes)
taxes = pd.concat(taxes)
taxes.to_csv(filepath, index=False)
taxes['date'] = pd.to_datetime(taxes['date'])
taxes.head()
| date | tax_type | tax_subtype | |
|---|---|---|---|
| 0 | 2017-01-20 | Сведения о среднесписочной численности работников | no_type |
| 1 | 2017-02-20 | Сведения о среднесписочной численности работников | no_type |
| 2 | 2017-03-20 | Сведения о среднесписочной численности работников | no_type |
| 3 | 2017-04-20 | Сведения о среднесписочной численности работников | no_type |
| 4 | 2017-05-22 | Сведения о среднесписочной численности работников | no_type |
if os.path.exists(holidays_filepath):
with open(holidays_filepath, 'rb') as f:
holidays = pickle.load(f)
else:
years = np.unique(data.index.year)
holidays = {
'holidays': [],
'preholidays': [],
'nowork': []
}
for year in years:
url = f'https://raw.githubusercontent.com/d10xa/holidays-calendar/master/json/consultant{year}.json'
r = requests.get(url)
cal = json.loads(r.text)
for key, val in cal.items():
holidays[key] += val
with open(holidays_filepath, 'wb') as f:
pickle.dump(holidays, f)
for key in holidays:
holidays[key] = pd.to_datetime(holidays[key])
print(holidays.keys())
holidays
dict_keys(['holidays', 'preholidays', 'nowork'])
{'holidays': DatetimeIndex(['2017-01-01', '2017-01-02', '2017-01-03', '2017-01-04',
'2017-01-05', '2017-01-06', '2017-01-07', '2017-01-08',
'2017-01-14', '2017-01-15',
...
'2021-11-28', '2021-12-04', '2021-12-05', '2021-12-11',
'2021-12-12', '2021-12-18', '2021-12-19', '2021-12-25',
'2021-12-26', '2021-12-31'],
dtype='datetime64[ns]', length=591, freq=None),
'preholidays': DatetimeIndex(['2017-02-22', '2017-03-07', '2017-11-03', '2018-02-22',
'2018-03-07', '2018-04-28', '2018-05-08', '2018-06-09',
'2018-12-29', '2019-02-22', '2019-03-07', '2019-04-30',
'2019-05-08', '2019-06-11', '2019-12-31', '2020-06-11',
'2020-11-03', '2020-12-31', '2021-02-20', '2021-04-30',
'2021-06-11', '2021-11-03'],
dtype='datetime64[ns]', freq=None),
'nowork': DatetimeIndex(['2020-03-30', '2020-03-31', '2020-04-01', '2020-04-02',
'2020-04-03', '2020-04-06', '2020-04-07', '2020-04-08',
'2020-04-09', '2020-04-10', '2020-04-13', '2020-04-14',
'2020-04-15', '2020-04-16', '2020-04-17', '2020-04-20',
'2020-04-21', '2020-04-22', '2020-04-23', '2020-04-24',
'2020-04-27', '2020-04-28', '2020-04-29', '2020-04-30',
'2020-05-06', '2020-05-07', '2020-05-08', '2020-06-24',
'2021-05-04', '2021-05-05', '2021-05-06', '2021-05-07',
'2021-11-01', '2021-11-02', '2021-11-03'],
dtype='datetime64[ns]', freq=None)}
with warnings.catch_warnings():
warnings.simplefilter("ignore")
rate_df = pd.read_excel(rate_filepath, engine="openpyxl")
rate_df['date'] = pd.to_datetime(rate_df['DT'])
rate_df = rate_df[['date', 'ruo']].rename(columns={'ruo': 'rate'})
rate_df = rate_df.sort_values('date').set_index('date')
rate_df.head()
| rate | |
|---|---|
| date | |
| 2017-01-09 | 10.13 |
| 2017-01-10 | 9.93 |
| 2017-01-11 | 9.97 |
| 2017-01-12 | 9.93 |
| 2017-01-13 | 10.06 |
with warnings.catch_warnings():
warnings.simplefilter("ignore")
inflation_df = pd.read_excel(inflation_filepath, engine="openpyxl")
inflation_df['Дата'] = inflation_df['Дата'].astype(str)
def fix_year(date_str):
parts = date_str.split('.')
if len(parts) == 2:
month, year = parts
if len(year) == 3:
year += '0'
return f"{month}.{year}"
return date_str
inflation_df['Дата'] = inflation_df['Дата'].apply(fix_year)
inflation_df['date'] = pd.to_datetime(inflation_df['Дата'], format='%m.%Y')
inflation_df = inflation_df[['date', 'Инфляция, % г/г']].rename(columns={'Инфляция, % г/г': 'inflation'})
inflation_df = inflation_df.sort_values('date').set_index('date')
inflation_df.head()
| inflation | |
|---|---|
| date | |
| 2017-01-01 | 5.0 |
| 2017-02-01 | 4.6 |
| 2017-03-01 | 4.3 |
| 2017-04-01 | 4.1 |
| 2017-05-01 | 4.1 |
currency_df = pd.read_csv(currency_path)
currency_df['date'] = pd.to_datetime(currency_df['date'])
currency_df = currency_df.rename(columns={'value': 'usd/rub'}).set_index('date')
currency_df = currency_df.sort_index()
currency_df.head()
| usd/rub | |
|---|---|
| date | |
| 2017-01-10 | 59.8961 |
| 2017-01-11 | 59.9533 |
| 2017-01-12 | 60.1614 |
| 2017-01-13 | 59.4978 |
| 2017-01-14 | 59.3700 |
moex_df = pd.read_csv(moex_path, encoding='windows-1251', sep=';')
moex_df['date'] = pd.to_datetime(moex_df['TRADEDATE'], format='%d.%m.%Y')
moex_df = pd.DataFrame(moex_df.set_index('date')['CLOSE'].str.replace(',', '.').astype(float)).rename(columns={'CLOSE': 'MOEX'})
moex_df = moex_df.sort_index()
moex_df.head()
| MOEX | |
|---|---|
| date | |
| 2017-01-03 | 2285.43 |
| 2017-01-04 | 2263.90 |
| 2017-01-05 | 2220.35 |
| 2017-01-06 | 2213.93 |
| 2017-01-09 | 2211.25 |
def make_ax_better(ax, locators=(), n_locators=None):
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
if 'x' in locators:
ax.xaxis.set_minor_locator(AutoMinorLocator(n_locators))
if 'y' in locators:
ax.yaxis.set_minor_locator(AutoMinorLocator(n_locators))
if locators:
ax.tick_params(which='minor', length=2.5)
ax.tick_params(which='major', length=5)
ax.grid(which='minor', linewidth=0.15, color='tab:grey', alpha=0.25)
ax.grid(linewidth=0.5, color='tab:grey', alpha=0.25)
def to_bold(s):
s = ' '.join([r"$\bf{" + str(item) + "}$" for item in s.split(' ')])
return s.replace('_', '}$_$\\bf{')
def plot_ts_plotly(
series_list: list[pd.Series],
colors: list[str] = ['#42CAFD'],
title: str = '',
xaxis_title: str = '',
yaxis_title: str = '',
fig_size: tuple[int, int] = (1000, 400), # Размер фигуры (ширина, высота)
v_lines: list[dict] = []
):
# Создаем фигуру
fig = go.Figure()
for series, color in zip(series_list, colors):
fig.add_trace(
go.Scatter(
x=series.index,
y=series,
mode='lines',
name=series.name,
line=dict(color=color),
meta=[series.name],
hovertemplate='<b>%{meta[0]}: %{y:.3f}</b><extra></extra>' # Добавляем название тикера
)
)
# Настраиваем заголовок и оси
fig.update_layout(
title={
'text': title,
'y': 0.95, # Позиция заголовка по вертикали
'x': 0.5, # Позиция заголовка по горизонтали (центр)
'xanchor': 'center', # Центрируем заголовок
'yanchor': 'top', # Привязка к верхней части
'font': dict(size=22) # Размер шрифта заголовка
},
xaxis_title=xaxis_title,
yaxis_title=yaxis_title,
template='plotly_white',
legend=dict(
x=0.5, # Центрируем легенду по горизонтали
y=-0.2, # Размещаем легенду ниже графика
xanchor='center', # Привязка к центру
yanchor='top', # Привязка к верхней части
orientation='h', # Горизонтальная ориентация
font=dict(size=12), # Размер шрифта легенды
traceorder='normal', # Порядок элементов легенды
itemwidth=50, # Ширина элемента легенды
itemsizing='constant', # Фиксированный размер элементов
bordercolor='lightgray', # Цвет границы легенды
borderwidth=1, # Ширина границы легенды
bgcolor='rgba(255, 255, 255, 0.8)', # Цвет фона легенды
# columns=legend_cols # Количество столбцов в легенде
),
hovermode='x unified',
width=fig_size[0], # Ширина фигуры
height=fig_size[1], # Высота фигуры
margin=dict(l=50, r=50, b=50, t=100) # Отступы (left, right, bottom, top)
)
# Настраиваем оси
fig.update_xaxes(
showgrid=True,
gridwidth=1,
gridcolor='lightgray',
minor=dict(
ticklen=4, # Длина minor-тиков
tickcolor='gray', # Цвет minor-тиков
showgrid=True, # Показываем minor-сетку
gridcolor='rgba(211, 211, 211, 0.5)', # Цвет minor-сетки (светло-серый с прозрачностью)
griddash='dot' # Стиль minor-сетки (точечный)
)
)
fig.update_yaxes(
showgrid=True,
gridwidth=1,
gridcolor='lightgray',
minor=dict(
ticklen=4, # Длина minor-тиков
tickcolor='gray', # Цвет minor-тиков
showgrid=True, # Показываем minor-сетку
gridcolor='rgba(211, 211, 211, 0.5)', # Цвет minor-сетки (светло-серый с прозрачностью)
griddash='dot' # Стиль minor-сетки (точечный)
)
)
for v_line_kws in v_lines:
fig.add_vline(
x=str(v_line_kws['x']),
line_width=2,
line_dash="dash",
line_color=v_line_kws.get('color', '#292F36'),
)
# Показываем график
fig.show()
def plot_decomposed_ts(
ts: pd.Series,
model: str = 'additive',
color: str = 'tab:blue'
):
decomposed = seasonal_decompose(ts, model=model, extrapolate_trend='freq')
decomposed = pd.concat([decomposed.observed, decomposed.seasonal, decomposed.trend, decomposed.resid], axis=1)
fig, axes = plt.subplots(figsize=(15, 16), nrows=4, dpi=150)
for col, ax in zip(decomposed, axes):
sns.lineplot(decomposed[col], ax=ax)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title(col, fontsize=25)
ax.set_xlabel('')
ax.set_xlim(decomposed.index.min() - pd.DateOffset(days=7), decomposed.index.max() + pd.DateOffset(days=7))
plt.tight_layout(h_pad=0.5)
Рассчитаем датафрейм, в который добавим сразу несколько рядов
data_df = data.copy()
inflation_df['year'] = inflation_df.index.year
inflation_df['month'] = inflation_df.index.month
data_df['year'] = data_df.index.year
data_df['month'] = data_df.index.month
data_with_inflation = data_df.merge(inflation_df, on=['year', 'month'], how='left')
data_with_inflation.index = data_df.index
data_with_inflation = data_with_inflation.drop(columns=['year', 'month'])
# Курсы валют и индексы
joined_df = data_with_inflation \
.merge(currency_df, left_index=True, right_index=True, how='left') \
.merge(moex_df, left_index=True, right_index=True, how='left')
joined_df[['usd/rub', 'MOEX']] = joined_df[['usd/rub', 'MOEX']].ffill().bfill()
# Изменения
joined_df['usd/rub'] = joined_df['usd/rub'].pct_change() * 100
joined_df['MOEX'] = joined_df['MOEX'].pct_change() * 100
joined_df[['usd/rub', 'MOEX']] = joined_df[['usd/rub', 'MOEX']].fillna(0)
joined_df = joined_df[['balance', 'inflation', 'usd/rub', 'MOEX']]
joined_df.head()
| balance | inflation | usd/rub | MOEX | |
|---|---|---|---|---|
| date | ||||
| 2017-01-09 | -0.155904 | 5.0 | 0.000000 | 0.000000 |
| 2017-01-10 | -0.125572 | 5.0 | 0.000000 | 1.186659 |
| 2017-01-11 | 0.007767 | 5.0 | 0.095499 | -0.843803 |
| 2017-01-12 | 0.800391 | 5.0 | 0.347103 | -0.297934 |
| 2017-01-13 | -0.019721 | 5.0 | -1.103033 | -0.759946 |
Посмотрим на исследуемый ряд
colors = ['#69995D', '#1985A1', '#C95D63']
for col, color in zip(data.columns, colors):
plot_ts_plotly([data[col]], title=col, colors=[color])
Попробуем разделить баланс на компоненты. Так как он может быть отрицательным, посмотрим только на аддитивные составляющие
plot_decomposed_ts(data['balance'])
Видно, что остатки остаются достаточно большими даже после разложения ряда, поэтому, скорее всего, нужны будут внешние факторы
Посмотрим на скользящее среднее для balance
fig, axes = plt.subplots(figsize=(20, 14), nrows=3, dpi=150)
colors = ['tab:blue', 'tab:orange', 'tab:green']
labels = ['Исходный ряд', 'Скользящее среднее с окном в неделю', 'Скользящее среднее с окном в месяц']
for ax, window, label, color in zip(axes, [0, 7, 30], labels, colors):
if window == 0:
df = data[['balance']]
else:
df = data[['balance']].rolling(window).mean()
df = df.reset_index(names=['date'])
sns.lineplot(
df, x='date', y='balance',
color=color,
ax=ax,
alpha=0.7
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_xlim(data.index.min(), data.index.max())
ax.set_title(label, fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
plt.tight_layout(h_pad=2)
Заметно, что после 2020 стало гораздо меньше положительных дней и больше отрицательных
fig, ax = plt.subplots(figsize=(14, 3), dpi=150)
bins_kws = {
'stat': 'percent', 'ax': ax,
'binrange': (-1.5, 1.5),
'bins': 50,
'alpha': 0.6
}
s1 = data[data.index.year < 2020].balance
s2 = data[data.index.year >= 2020].balance
sns.histplot(s1, **bins_kws, label='До 2020')
sns.histplot(s2, **bins_kws, label='После 2020')
ax.set_title('Распределение дневного сальдо')
make_ax_better(ax)
ax.set_ylabel('')
ax.set_xlabel('')
ax.legend()
plt.show()
display(pd.DataFrame(s1.describe().round(3).rename('До 2020')).T)
display(pd.DataFrame(s2.describe().round(3).rename('После 2020')).T)
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| До 2020 | 1087.0 | -0.012 | 0.286 | -2.38 | -0.094 | 0.0 | 0.083 | 1.41 |
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| После 2020 | 456.0 | -0.137 | 0.288 | -2.51 | -0.245 | -0.01 | 0.0 | 0.498 |
Проверим стационарность по ADF до 2020 и после 2020
# ADF Test
result = adfuller(s1, autolag='AIC')
print('Данные до 2020')
print(f'ADF Statistic: {result[0]}')
print(f'p-value: {result[1]}')
print('\nДанные после 2020')
result = adfuller(s2, autolag='AIC')
print(f'ADF Statistic: {result[0]}')
print(f'p-value: {result[1]}\n')
for key, value in result[4].items():
print('Critial Values:')
print(f' {key}, {value}')
Данные до 2020 ADF Statistic: -8.310292749794408 p-value: 3.8050701720556535e-13 Данные после 2020 ADF Statistic: -2.529280361872583 p-value: 0.10851164759906601 Critial Values: 1%, -3.445231637930579 Critial Values: 5%, -2.8681012763264233 Critial Values: 10%, -2.5702649212751583
Видно, что до 2020 года ряд был стационарным, а после уже перестал
Первичные найденные особенности1. income и outcome имеют тренд и растут с начала промежутка к концу (возможно, следствие инфляции).¶
balanceпосле 2020 года в большей степени находится в отрицательной зоне и даже перестает быть стационарным (хотя ранее был). Визуально наблюдается тренд снижения сальдо с 2020 года и рост дисперсии, а по распределению сальдо видно, как его среднее смещается в отрицательную зону.- Разложение на компоненты оставляет достаточно закономерные ошибки в остатках, поэтому нужно смотреть на внешние факторы.
Исходя из ранее отмеченных предпосылок рассмотрим детальнее данные после 2020 года
clear_data = data[data.index.year >= 2020].copy()
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
clear_data.balance.plot(ax=ax)
make_ax_better(ax, locators=['x', 'y'])
Сразу отметим, что наблюдается что-то странное в конце промежутка (очень большой отток, причина которого пока не ясна)
clear_data[-8:-3]
| income | outcome | balance | |
|---|---|---|---|
| date | |||
| 2021-03-24 | 1.94 | 2.53 | -0.587778 |
| 2021-03-25 | 2.30 | 3.17 | -0.869810 |
| 2021-03-26 | 2.17 | 4.69 | -2.510000 |
| 2021-03-27 | 0.00 | 0.00 | 0.000000 |
| 2021-03-28 | 0.00 | 0.00 | 0.000000 |
Вероятно, судя по графикам и дате, в этот день произошла крупная выплата налога.
Ну, или...

Посмотрим на нули в данных
values_by_sign = clear_data.copy()
values_by_sign['flag'] = values_by_sign['balance'].apply(
func=lambda x: 0 if x == 0 else (1 if x > 0 else (2 if x < 0 else np.nan))
)
pd.concat([
values_by_sign['flag'].value_counts(),
values_by_sign['flag'].value_counts(normalize=True)*100,
], axis=1)
| count | proportion | |
|---|---|---|
| flag | ||
| 2 | 256 | 56.140351 |
| 0 | 111 | 24.342105 |
| 1 | 89 | 19.517544 |
24% данных это нули, при этом в 19.5% значения больше нуля, а в 56% дней меньше нуля. Посмотрим на причину нулей.
values_by_sign[values_by_sign['flag']==0].index.dayofweek.value_counts()
date 6 57 5 45 2 3 4 3 1 2 3 1 Name: count, dtype: int64
В большинстве своем нули на выходных, однако есть нули и в другие дни, и, кроме того, вероятно, что если у нас выходной, то это не значит, что будет нулевое сальдо
weekdays_data = clear_data.reset_index().copy()
weekdays_data['is_holiday'] = weekdays_data['date'].isin(holidays['holidays'])
weekdays_data['is_nowork'] = weekdays_data['date'].isin(holidays['nowork'])
weekdays_data['dayofweek'] = weekdays_data['date'].dt.dayofweek
weekdays_data.groupby(['dayofweek', 'is_holiday', 'is_nowork'])['balance'].describe()
| count | mean | std | min | 25% | 50% | 75% | max | |||
|---|---|---|---|---|---|---|---|---|---|---|
| dayofweek | is_holiday | is_nowork | ||||||||
| 0 | False | False | 52.0 | -0.438192 | 0.321565 | -1.268604 | -0.585338 | -0.319524 | -0.210214 | 1.110426e-01 |
| True | 5.0 | -0.337385 | 0.182710 | -0.542662 | -0.500265 | -0.329129 | -0.161410 | -1.534603e-01 | ||
| True | False | 8.0 | -0.042156 | 0.058171 | -0.145435 | -0.087801 | -0.004867 | -0.000007 | -4.608431e-09 | |
| 1 | False | False | 56.0 | -0.237236 | 0.315035 | -1.470000 | -0.366815 | -0.216313 | -0.054176 | 2.258344e-01 |
| True | 5.0 | -0.134236 | 0.193194 | -0.408989 | -0.235376 | -0.082896 | -0.036284 | 9.236324e-02 | ||
| True | False | 4.0 | -0.001345 | 0.007902 | -0.012179 | -0.003045 | 0.000000 | 0.001700 | 6.798414e-03 | |
| 2 | False | False | 54.0 | -0.125936 | 0.253519 | -0.873672 | -0.246804 | -0.120690 | 0.083994 | 2.331369e-01 |
| True | 7.0 | -0.111769 | 0.389235 | -0.953475 | -0.117703 | 0.000000 | 0.127601 | 1.512980e-01 | ||
| True | False | 5.0 | -0.006042 | 0.012861 | -0.029040 | -0.000828 | -0.000342 | 0.000000 | 0.000000e+00 | |
| 3 | False | False | 57.0 | -0.121022 | 0.260412 | -0.869810 | -0.310758 | -0.091844 | 0.065129 | 3.383190e-01 |
| True | 6.0 | 0.055505 | 0.248341 | -0.339241 | -0.085235 | 0.149735 | 0.224378 | 2.871016e-01 | ||
| True | False | 2.0 | -0.000010 | 0.000014 | -0.000020 | -0.000015 | -0.000010 | -0.000005 | 0.000000e+00 | |
| 4 | False | False | 55.0 | -0.164068 | 0.421128 | -2.510000 | -0.313004 | -0.119412 | 0.075294 | 4.984655e-01 |
| True | 5.0 | -0.026249 | 0.074127 | -0.128825 | -0.064412 | -0.015557 | 0.012027 | 6.552262e-02 | ||
| True | False | 5.0 | -0.042225 | 0.060164 | -0.129086 | -0.082038 | 0.000000 | 0.000000 | 0.000000e+00 | |
| 5 | False | False | 1.0 | -0.266053 | NaN | -0.266053 | -0.266053 | -0.266053 | -0.266053 | -2.660526e-01 |
| True | False | 64.0 | 0.001213 | 0.018652 | -0.027359 | 0.000000 | 0.000000 | 0.000000 | 1.345602e-01 | |
| 6 | True | False | 65.0 | -0.000238 | 0.002542 | -0.017241 | 0.000000 | 0.000000 | 0.000000 | 8.004304e-03 |
Как и предполагалось, даже в выходные бывают перетоки. После обсуждения с заказчиком установили, что перетоки в выходные могут не учитываться моделью и можно по умолчанию считать, что в выходные balance = 0.
Как мы помним, ряд после 2020 года на уровнях значимости 0.05 и 0.1 не стационарен, поэтому перейдем к разностям, чтобы посмотреть на автокорреляции
clear_data_diff = clear_data.diff().dropna()
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
clear_data_diff.balance.plot(ax=ax)
make_ax_better(ax, locators=['x', 'y'])
result = adfuller(clear_data_diff.balance, autolag='AIC')
print(f'ADF Statistic: {result[0]}')
print(f'p-value: {result[1]}')
for key, value in result[4].items():
print('Critial Values:')
print(f' {key}, {value}')
ADF Statistic: -9.552603335696045 p-value: 2.5481185631094414e-16 Critial Values: 1%, -3.445231637930579 Critial Values: 5%, -2.8681012763264233 Critial Values: 10%, -2.5702649212751583
Исходя из дисперсии, даже несмотря на результаты теста видно, что ее тяжело назвать постоянной, но придется с этим жить
fig, axes = plt.subplots(figsize=(25, 6), dpi=150, ncols=2)
plot_acf(clear_data_diff.balance.values, ax=axes[0], lags=60)
plot_pacf(clear_data_diff.balance.values, ax=axes[1], lags=60)
for ax in axes:
make_ax_better(ax)
ax.set_xticks([i for i in range(0, 61, 2)])
plt.show()
В целом видно, что есть автокорреляции, причем отрицательные в большей степени
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = data[['balance']].rolling(7).mean().reset_index()
sns.lineplot(
df, x='date', y='balance',
color=color,
ax=ax,
alpha=0.7,
label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_xlim(data.index.min(), data.index.max())
ax.set_title('Скользящее среднее за неделю сальдо и ставка кредитования', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()
rate_ax = ax.twinx()
sns.lineplot(
rate_df.reset_index(), x='date', y='rate',
color='tab:red',
ax=rate_ax,
alpha=0.7,
label='ROUNIA, %'
)
h2, l2 = rate_ax.get_legend_handles_labels()
ax.legend(
h1+h2, l1+l2
)
rate_ax.set_ylabel('ROUNIA, %')
rate_ax.get_legend().remove()
rate_ax.set_xlabel('')
plt.show()
В целом видно, что ставка в 2019-2020 году шла по траектории снижения, поэтому, вероятно, могли быть оттоки с банковских депозитов на более доходные инструменты (фондовый рынок) и, например, на потребление, что для банка выражалось в отрицательном сальдо, однако напрямую эти данные вряд ли получится использовать.
Еще интересно, что резкие пики по сальдо приводили к скачам RUONIA впоследствии, посмотрим на это детальнее.
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = data[['balance']].rolling(7).mean().reset_index().tail(365)
sns.lineplot(
df, x='date', y='balance',
color=color,
ax=ax,
alpha=0.7,
label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за неделю сальдо и ставка кредитования (данные за 1 год)', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()
rate_ax = ax.twinx()
sns.lineplot(
rate_df.reset_index().tail(252), x='date', y='rate',
color='tab:red',
ax=rate_ax,
alpha=0.7,
label='ROUNIA, %'
)
h2, l2 = rate_ax.get_legend_handles_labels()
ax.legend(
h1+h2, l1+l2
)
rate_ax.set_ylabel('ROUNIA, %')
rate_ax.get_legend().remove()
rate_ax.set_xlabel('')
plt.show()
Видно, как за резким снижением сальдо следовало с задержкой снижение ставки в некоторых моментах
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = joined_df[['balance', 'MOEX']].rolling(30).mean().reset_index()
# Первая ось — сальдо
sns.lineplot(
df, x='date', y='balance',
color='#1985A1',
ax=ax,
alpha=0.7,
label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за месяц: сальдо и MOEX (данные за 1 год)', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()
# Вторая ось — инфляция
infl_ax = ax.twinx()
sns.lineplot(
df, x='date', y='MOEX',
color='tab:orange',
ax=infl_ax,
alpha=0.7,
label='MOEX, %'
)
h2, l2 = infl_ax.get_legend_handles_labels()
# Объединённая легенда
ax.legend(h1 + h2, l1 + l2)
infl_ax.set_ylabel('MOEX, %')
infl_ax.get_legend().remove()
infl_ax.set_xlabel('')
plt.show()
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = joined_df[['balance', 'usd/rub']].rolling(30).mean().reset_index()
# Первая ось — сальдо
sns.lineplot(
df, x='date', y='balance',
color='#1985A1',
ax=ax,
alpha=0.7,
label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за месяц: сальдо и USD/RUB', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()
# Вторая ось — инфляция
infl_ax = ax.twinx()
sns.lineplot(
df, x='date', y='usd/rub',
color='tab:orange',
ax=infl_ax,
alpha=0.7,
label='USD/RUB, %'
)
h2, l2 = infl_ax.get_legend_handles_labels()
# Объединённая легенда
ax.legend(h1 + h2, l1 + l2)
infl_ax.set_ylabel('USD/RUB, %')
infl_ax.get_legend().remove()
infl_ax.set_xlabel('')
plt.show()
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = joined_df[['balance', 'inflation']].rolling(30).mean().reset_index()
# Первая ось — сальдо
sns.lineplot(
df, x='date', y='balance',
color='tab:red',
ax=ax,
alpha=0.7,
label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за месяц: сальдо и инфляция', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()
# Вторая ось — инфляция
infl_ax = ax.twinx()
sns.lineplot(
df, x='date', y='inflation',
color='tab:green',
ax=infl_ax,
alpha=0.7,
label='USD/RUB, %'
)
h2, l2 = infl_ax.get_legend_handles_labels()
# Объединённая легенда
ax.legend(h1 + h2, l1 + l2)
infl_ax.set_ylabel('Инфляция, %')
infl_ax.get_legend().remove()
infl_ax.set_xlabel('')
plt.show()
Возможно, оттоки увеличиваются именно из-за изменения инфляции, по крайней мере в них наблюдаются контртренды
Описание пайплайна модели¶
Модель основывается на CatBoost.
- Подготовка данных
- Для бейзлайна: временной ряд (
balance) и датафрейм с датами налогов в разрезе типов. - Для модели с внешними факторами добавляется также индекс мосбиржи, инфляция и курс рубля.
- Для бейзлайна: временной ряд (
- Подготовка признаков (feature engineering)
- Временные признаки: день недели, месяца, месяц, флаги интервалов дней в месяце с 15 по 19, с 20 по 24, после 25, выходной/предпраздничный день.
- Лаговые признаки для целевой переменной: значения 1-30 дней назад.
- Флаги выплаты налога данного типа в выбранный день.
- Лаговые признаки для внешних факторов (для модели с внешними факторами): изменения индекса мосбиржи, курса доллара и инфляции.
- Выбор признаков (feature selection)
- Были рассмотрены различные подходы, однако в качестве наиболее подходящего (сочетание качества и скорости) используется внутренний (embedded) метод ранжирования признаков по значимости (на основе встроенного feature_importance в базовой модели CatBoost). Порог выбирается так, чтобы отобрать не менее половины всех исходных признаков на каждом фолде.
- Для отбора признаков используется кросс-валидация с фиксированным размером тренировочной и тестовой выборок. На каждой выборке тренируется и тестируется модель, в результате чего выбирается не менее половины всех признаков. Далее по результатам кросс-валидации отбираются признаки, которые были выбраны не менее, чем в половине всех фолдов.
- Подбор гиперпараметров (hyperparameters optimizing)
- Для отбора наилучшего набора гиперпараметров CatBoost используется библиотека Optuna с кросс-валидацией.
- Тренировка финальной модели
- На основе полученных гиперпараметров происходит тренировка модели на последнем доступном промежутке, после чего модель готова предсказывать баланс на следующий день.
Промежуток обучения и дообучения¶
Для обучения модели рассматривается промежуток в 32 последних недели, а горизонт предсказания - 1 день. При этом так как внутри используется кросс-валидация, то для полноценной тренировки нужен набор данных, содержащий несколько фолдов, поэтому рассматривается промежуток с начала 2020 года (момент, когда логика изменилась и баланс стал быть более часто отрицательным).
Предполагается, что модель будет дообучаться каждую неделю, что обосновывается несколькими факторами:
- В выходные нет активных транзакций, поэтому удобно использовать это время для переобучения модели.
- Неделя - элемент периодичности нашей модели, поэтому дополнять каждый раз модель новой неделей представляется логичным, чтобы пополнять цепочку накопленных знаний и поддерживать актуальность.
Выбор метрики¶
Бизнес-постановка¶
Введем несколько величин:
- $r_{key}$ - ключевая ставка
- $r_{add}$ - ставка при размещении деривативов для получения дополнительной маржи. $r_{add} = r_{key} + 0.5$
- $r_{surplus}$ - ставка по профицитной ликвидности, размещаемой по overnight-ставке в ЦБ. $r_{surplus} = r_{key} - 0.9$
- $r_{deficit}$ - ставка по дефицитной ликвидности, которую приходится привлекать за счет займа в ЦБ. $r_{deficit} = r_{key} + 1$
Предположим, что у нас есть некоторое предсказание $\hat{b}_t$ - сальдо на день $t$, а также реальное значение ${b}_t$ сальдо за этот день. Существует 2 основных сценария действий:
- $\hat{b}_t > 0$, тогда полученные средства могут быть размещены в деривативы для получения маржи под ставку $r_{add}$, таким образом образуется дополнительная доходность $\hat{b}_t \cdot r_{add}$.
- $\hat{b}_t < 0$, тогда необходимо заимствовать деньги под $r_{deficit}$, таким образом генерируется убыток $\hat{b}_t \cdot r_{deficit}$
При этом в зависимости от того, какое сальдо будет по факту, мы можем попасть в две ситуации:
- $\hat{b}_t > b_t$, то есть было предсказано больше, чем пришло по факту. Таким образом, нам придется занимать у цб $\hat{b}_t-b_t$ средств под ставку $r_{deficit}$, чтобы покрыть дефицит на день, при этом мы заработаем $\hat{b}_t \cdot r_{add}$ за счет размещения средств в деривативы.
- $\hat{b}_t < b_t$, то есть было предсказано меньше, чем пришло по факту. Таким образом, потеря будет в том, что мы недозаработали $(b_t - \hat{b}_t) \cdot (r_{add} - r_{surplus})$ денег, так как вместо размещения в деривативы будем вынуждены размещать под overnight.
Использование метрики в оптимизации¶
Выбранная метрика используется в TargetLoss-классе для того, чтобы наиболее оптимальным образом подбирать гиперпараметры для модели. При этом также выбранная метрика дополнительно используется в CatBoost в качестве кастомной, что обеспечивает нацеленность модели на бизнес-результат.
Кроме того, так как есть пожелание со стороны заказчика о том, что ошибка в абсолютном значении не должна превосходить 0.42, то это также учитывается за счет добавления фиксированного штрафа в ошибку в случае превышения этой границы моделью.
Выбор признаков (feature selection)¶
Всего рассмотрены 3 метода отбора признаков:
- Встроенный (на основе feature importance в catboost)
- Оберточный
- Фильтрационный (взаимная информация признака и таргета)
Для выбора метода был проведен анализ стабильности выбора признаков по сплитам кросс-валидации. Рассматриваемые метрики для сравнения:
diversity_index (Индекс разнообразия)Формула:¶
$$ D = \frac{-\sum p_j \ln p_j}{\ln N}, \quad \text{где } p_j = \frac{\text{Частота выбора признака } j}{n} $$
Что показывает:
Равномерность распределения частот выбора признаков.
Интерпретация:
D = 1: Все признаки выбираются одинаково часто (максимальное разнообразие).D = 0: Один признак выбирается всегда (минимальное разнообразие).
Примечание:
- Чем ближе значение к 1, тем выше разнородность выбора признаков.
- Чем ближе к 0, тем сильнее доминирование отдельных признаков.
pairwise_jaccard (Попарный Жаккар)Формула:¶
$$ J = \frac{1}{C(n,2)} \sum J(A_i, A_j) $$
Что показывает:
Среднее сходство всех пар фолдов.
Интерпретация:
- $J > 0.7$: Высокая стабильность.
- $J < 0.3$: Низкая стабильность.
feature_consistency (Консистентность)Формула:¶
$$ C = \frac{1}{N} \sum \frac{\text{Выборов признака}}{\text{Фолдов}} $$
Что показывает:
Среднюю частоту выбора каждого признака.
Интерпретация:
- $С = 1 $: Признак выбирается всегда..
- $С = 0 $: Признак не выбирается никогда.
# Импортируем нужные библиотеки и реализовальный модуль Feature_selection
from src.feature_selection import selectors
from sklearn.metrics import jaccard_score
import time
from itertools import combinations
from scipy.stats import entropy
def diversity_index(masks):
"""Индекс разнообразия на основе энтропии
Вычисляет нормализованную энтропию частот выбора признаков.
"""
selection_freq = np.mean(masks, axis=0)
return entropy(selection_freq)/np.log(len(selection_freq))
def pairwise_jaccard(masks):
"""Средний попарный коэффициент Жаккара
Вычисляет среднее сходство выбора признаков
между всеми парами масок (бинарных векторов)
"""
pairs = list(combinations(masks, 2))
return np.mean([jaccard_score(a, b) for a, b in pairs])
def feature_consistency(masks):
"""Средняя консистентность признаков
Вычисляет среднюю частоту выбора признаков
по всем фолдам:
"""
return np.mean(np.sum(masks, axis=0) / len(masks))
# Функция для расчета всех метрик
def calculate_all_metrics(masks):
masks_array = np.array(masks)
return {
'pairwise_jaccard': pairwise_jaccard(masks),
'feature_consistency': feature_consistency(masks),
'diversity_index': diversity_index(masks_array),
}
# Получим из нашей модели данные и фичи
model = BaselineModel(
hyperparams_optimizer_kws = {'optuna_n_trials': 1}
)
X, y = model.prepare_data(df=data, taxes=taxes, holidays=holidays)
# Используем функцию из нашей модели для кросс валидации
from src.model_evaluation.cross_validation import (
split_period_for_cross_val
)
df = data.copy()
df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date')
min_date = df.index.min()
max_date = df.index.max()
splits = split_period_for_cross_val(
min_date=min_date,
max_date=max_date,
test_size_weeks=1,
train_size_weeks=32,
n_folds=5,
seed=42
)
# Преобразование существующих сплитов
sorted_splits = sorted(
[(np.array(train), np.array(test)) for train, test in splits],
key=lambda x: x[1][0] if len(x[1]) > 0 else 0
)
embedded_selector = selectors.SelectFromModelEmbeddedFeatureSelector()
wrapper_selector = selectors.WrapperFeatureSelector()
filter_selector = selectors.FilterFeatureSelector()
stability_results = {
"embedded": {"masks": [], "time": []},
"wrapper": {"masks": [], "time": []},
"filter": {"masks": [], "time": []}
}
for fold_number, (train_index, test_index) in enumerate(sorted_splits):
X_train, X_test = X.iloc[train_index], X.iloc[test_index]
y_train, y_test = y.iloc[train_index], y.iloc[test_index]
print(f"\nFold {fold_number + 1}:")
for method in ['embedded', 'wrapper', 'filter']:
start_time = time.perf_counter()
if method == 'embedded':
feats = embedded_selector.select_features(X_train, y_train)
elif method == 'wrapper':
feats = wrapper_selector.select_features(X_train, y_train)
else:
feats = filter_selector.select_features(X_train, y_train)
exec_time = time.perf_counter() - start_time
mask = X.columns.isin(feats).astype(int)
# Сохраняем результаты
stability_results[method]['masks'].append(mask)
stability_results[method]['time'].append(exec_time)
print(f"{method.upper()} selected features in {exec_time:.4f}s")
Fold 1: EMBEDDED selected features in 1.3603s WRAPPER selected features in 56.6791s FILTER selected features in 0.0930s Fold 2: EMBEDDED selected features in 1.2940s WRAPPER selected features in 54.1022s FILTER selected features in 0.0897s Fold 3: EMBEDDED selected features in 1.3143s WRAPPER selected features in 53.7507s FILTER selected features in 0.0888s Fold 4: EMBEDDED selected features in 1.3732s WRAPPER selected features in 54.3163s FILTER selected features in 0.0916s Fold 5: EMBEDDED selected features in 1.3705s WRAPPER selected features in 53.5609s FILTER selected features in 0.0914s
# Собираем результаты
final_results = {}
for method in ['embedded', 'wrapper', 'filter']:
masks = np.array(stability_results[method]['masks'])
final_results[method] = {
'metrics': calculate_all_metrics(masks),
'avg_time': np.mean(stability_results[method]['time'])
}
# Собираем данные в плоский формат
tmp = []
for method in ['embedded', 'wrapper', 'filter']:
row = {
'Method': method,
'Average Time': final_results[method]['avg_time']
}
row.update(final_results[method]['metrics'])
tmp.append(row)
# Создаем DataFrame
df = pd.DataFrame(tmp).set_index('Method').T
df.index.name = 'Metric'
df = df.reset_index()
df.columns = ['Metric', 'EMBEDDED', 'WRAPPER', 'FILTER']
is_metric_row = df['Metric'] != 'Average Time' # Фильтр по названию метрики
is_time_row = df['Metric'] == 'Average Time' # Фильтр для времени
# Форматируем вывод
styled_df = df.style \
.hide(axis='index') \
.format(
formatter="{:.4f}",
subset=pd.IndexSlice[:, ['EMBEDDED', 'WRAPPER', 'FILTER']]
) \
.set_caption('Сравнение методов отбора признаков') \
.set_properties(**{
'text-align': 'center',
'border': '1px solid #dee2e6',
'padding': '5px'
}) \
.set_table_styles([{
'selector': 'th',
'props': [('background-color', '#f8f9fa')]
}]) \
.applymap(
lambda x: 'color: green' if (isinstance(x, float) and x >= 0.7) else '',
subset=pd.IndexSlice[is_metric_row, ['EMBEDDED', 'WRAPPER', 'FILTER']]
) \
.applymap(
lambda x: 'color: red' if (isinstance(x, float) and x <= 0.3) else '',
subset=pd.IndexSlice[is_metric_row, ['EMBEDDED', 'WRAPPER', 'FILTER']]
# Градиент для времени
) \
.background_gradient(
subset=pd.IndexSlice[is_time_row, ['EMBEDDED', 'WRAPPER', 'FILTER']],
cmap='YlOrRd', # Красно-желто-зеленый градиент
vmin=0, # Минимальное время (подстройте под ваши данные)
vmax=50
)
styled_df
C:\Users\ilya\AppData\Local\Temp\ipykernel_17276\3450108959.py:31: FutureWarning: Styler.applymap has been deprecated. Use Styler.map instead.
| Metric | EMBEDDED | WRAPPER | FILTER |
|---|---|---|---|
| Average Time | 1.3425 | 54.4818 | 0.0909 |
| pairwise_jaccard | 0.8765 | 0.8701 | 0.5357 |
| feature_consistency | 0.5407 | 0.4938 | 0.4938 |
| diversity_index | 0.8768 | 0.8573 | 0.9248 |
Вывод:
- FILTER-метод — самый быстрый (
0.09 сек) и разнообразный (diversity=0.92), но наименее стабильный (Jaccard=0.53). - EMBEDDED — оптимален для баланса скорости (
1.34 сек) и стабильности (Jaccard=0.87). - WRAPPER — крайне медленный (
54.48 сек), но стабильный — подходит для точного отбора при наличии ресурсов.
train_data = clear_data[['balance']].reset_index()
train_df = train_data[:-28].copy()
test_df = train_data[-28:].copy()
model = BaselineModel(
cross_val_split_kws = {
'train_size_weeks': 32,
'test_size_weeks': 1,
'n_folds': 5
},
hyperparams_optimizer_kws = {
'optuna_n_trials': 50,
'cross_val_score_kws': {
'loss': TargetLoss(rate_df['rate']),
'additional_metrics': {
'max_ae': MaxAE(),
'mae': MAE(),
'num_errors_over_limit': NumCriticalErrors(),
'money_loss': MoneyLoss(rate_df['rate'])
}
}
},
feature_selector_kws={'threshold': '0.75*median'}
)
model.fit(df=train_df, taxes=taxes, holidays=holidays)
[I 2025-04-27 21:56:25,537] A new study created in memory with name: no-name-5e944279-b877-4c95-9985-b5032b14f356
[I 2025-04-27 21:56:34,414] Trial 0 finished with value: 0.8001118486549157 and parameters: {'iterations': 1724, 'learning_rate': 0.0025638433420732123, 'depth': 5, 'l2_leaf_reg': 0.5560508061057889, 'random_strength': 3.0532064296169783, 'bagging_temperature': 0.8074583720997661, 'max_ctr_complexity': 1}. Best is trial 0 with value: 0.8001118486549157.
[I 2025-04-27 21:56:43,304] Trial 1 finished with value: 0.6000987038118161 and parameters: {'iterations': 1270, 'learning_rate': 0.013044907802805577, 'depth': 5, 'l2_leaf_reg': 0.027369441764082774, 'random_strength': 0.8824350467993378, 'bagging_temperature': 0.31349373473780573, 'max_ctr_complexity': 3}. Best is trial 1 with value: 0.6000987038118161.
[I 2025-04-27 21:56:48,127] Trial 2 finished with value: 0.6001040369682398 and parameters: {'iterations': 473, 'learning_rate': 0.13761958257518794, 'depth': 7, 'l2_leaf_reg': 1.0925094027435385, 'random_strength': 9.982330578501154, 'bagging_temperature': 1.7880524331870002, 'max_ctr_complexity': 1}. Best is trial 1 with value: 0.6000987038118161.
[I 2025-04-27 21:56:58,565] Trial 3 finished with value: 0.6000926651194716 and parameters: {'iterations': 1190, 'learning_rate': 0.036312113722904445, 'depth': 6, 'l2_leaf_reg': 0.2536496285433246, 'random_strength': 4.648802973057262, 'bagging_temperature': 0.8653310000045378, 'max_ctr_complexity': 4}. Best is trial 3 with value: 0.6000926651194716.
[I 2025-04-27 21:56:59,888] Trial 4 finished with value: 0.8001071055440236 and parameters: {'iterations': 355, 'learning_rate': 0.04023930291824554, 'depth': 3, 'l2_leaf_reg': 5.195551178070165, 'random_strength': 4.0468620251214515, 'bagging_temperature': 0.46050741529178163, 'max_ctr_complexity': 1}. Best is trial 3 with value: 0.6000926651194716.
[I 2025-04-27 21:57:02,958] Trial 5 finished with value: 0.6000913603761562 and parameters: {'iterations': 1036, 'learning_rate': 0.02151458463502839, 'depth': 2, 'l2_leaf_reg': 0.012881383929544153, 'random_strength': 8.565193957430631, 'bagging_temperature': 0.7958453093283946, 'max_ctr_complexity': 3}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:26,533] Trial 6 finished with value: 0.6001163859857133 and parameters: {'iterations': 1955, 'learning_rate': 0.001654450703604335, 'depth': 7, 'l2_leaf_reg': 0.5935769619334545, 'random_strength': 2.510864754562687, 'bagging_temperature': 1.4253684301564378, 'max_ctr_complexity': 4}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:28,420] Trial 7 finished with value: 0.8001088870528112 and parameters: {'iterations': 492, 'learning_rate': 0.010054832785391392, 'depth': 3, 'l2_leaf_reg': 0.005180996813471535, 'random_strength': 2.4044915766417176, 'bagging_temperature': 0.4333589751462297, 'max_ctr_complexity': 2}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:38,244] Trial 8 finished with value: 0.600094972742379 and parameters: {'iterations': 785, 'learning_rate': 0.2608422593801981, 'depth': 7, 'l2_leaf_reg': 0.25949298130909143, 'random_strength': 4.474022282855026, 'bagging_temperature': 0.6842423182069111, 'max_ctr_complexity': 4}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:50,495] Trial 9 finished with value: 0.6001083211017784 and parameters: {'iterations': 1728, 'learning_rate': 0.0463611678946233, 'depth': 5, 'l2_leaf_reg': 0.018489197073098287, 'random_strength': 0.29236504299259153, 'bagging_temperature': 1.0353926286279538, 'max_ctr_complexity': 2}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:52,799] Trial 10 finished with value: 0.600120683584568 and parameters: {'iterations': 904, 'learning_rate': 0.004998707221879419, 'depth': 2, 'l2_leaf_reg': 0.00010669776009727384, 'random_strength': 7.728790556988808, 'bagging_temperature': 0.01849031839681259, 'max_ctr_complexity': 3}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:58:03,810] Trial 11 finished with value: 0.8001035540316709 and parameters: {'iterations': 1220, 'learning_rate': 0.03723044081714964, 'depth': 6, 'l2_leaf_reg': 0.0015068759114238162, 'random_strength': 6.733500534556204, 'bagging_temperature': 1.1408250434008833, 'max_ctr_complexity': 4}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:58:07,298] Trial 12 finished with value: 0.4000807564177301 and parameters: {'iterations': 1387, 'learning_rate': 0.0753198505973765, 'depth': 2, 'l2_leaf_reg': 0.12678096001997655, 'random_strength': 6.691049075961274, 'bagging_temperature': 1.3100242532173638, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:11,091] Trial 13 finished with value: 0.800087670098943 and parameters: {'iterations': 1500, 'learning_rate': 0.12357279728414654, 'depth': 2, 'l2_leaf_reg': 0.0011605137366224132, 'random_strength': 7.121196729254168, 'bagging_temperature': 1.381915016266066, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:14,188] Trial 14 finished with value: 0.6000953239212864 and parameters: {'iterations': 776, 'learning_rate': 0.06963346127688054, 'depth': 3, 'l2_leaf_reg': 0.06275477080274464, 'random_strength': 9.364423506775218, 'bagging_temperature': 1.9647973983088467, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:17,972] Trial 15 finished with value: 0.800091715852712 and parameters: {'iterations': 1451, 'learning_rate': 0.018912475558088886, 'depth': 2, 'l2_leaf_reg': 0.061358153435259764, 'random_strength': 8.204319255258163, 'bagging_temperature': 1.3535466047234408, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:23,537] Trial 16 finished with value: 0.6001126365678349 and parameters: {'iterations': 1005, 'learning_rate': 0.0062843288037088794, 'depth': 4, 'l2_leaf_reg': 0.007690469612092923, 'random_strength': 5.975549732021241, 'bagging_temperature': 1.7050279403476305, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:31,540] Trial 17 finished with value: 0.6000962941271242 and parameters: {'iterations': 1426, 'learning_rate': 0.019900620792820657, 'depth': 4, 'l2_leaf_reg': 5.553948381510655, 'random_strength': 8.885026252376164, 'bagging_temperature': 1.1605361470268294, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:34,305] Trial 18 finished with value: 0.8000971360277175 and parameters: {'iterations': 649, 'learning_rate': 0.09426821689406721, 'depth': 3, 'l2_leaf_reg': 0.0004724468397456568, 'random_strength': 5.899055193119633, 'bagging_temperature': 0.6635740638225025, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:34,878] Trial 19 finished with value: 0.6000835583331281 and parameters: {'iterations': 196, 'learning_rate': 0.24271396722655453, 'depth': 2, 'l2_leaf_reg': 0.11367105643520259, 'random_strength': 8.425305561472008, 'bagging_temperature': 1.5803434793436941, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:36,178] Trial 20 finished with value: 0.6000955641936111 and parameters: {'iterations': 235, 'learning_rate': 0.24629151329731785, 'depth': 4, 'l2_leaf_reg': 0.13269596722479435, 'random_strength': 5.853975765029121, 'bagging_temperature': 1.6088970392835915, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:36,594] Trial 21 finished with value: 0.800095117756667 and parameters: {'iterations': 127, 'learning_rate': 0.17716642647652764, 'depth': 2, 'l2_leaf_reg': 0.01040124572948982, 'random_strength': 8.144682269241772, 'bagging_temperature': 1.4843052517286903, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:40,196] Trial 22 finished with value: 0.6000857255589549 and parameters: {'iterations': 1669, 'learning_rate': 0.07597106179792484, 'depth': 2, 'l2_leaf_reg': 0.06767656459147055, 'random_strength': 7.102875095611108, 'bagging_temperature': 1.2542293621474248, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:45,407] Trial 23 finished with value: 0.6000828174452322 and parameters: {'iterations': 1672, 'learning_rate': 0.08872294838090046, 'depth': 3, 'l2_leaf_reg': 1.545478780340116, 'random_strength': 6.9868152978927345, 'bagging_temperature': 1.2558280854379535, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:51,770] Trial 24 finished with value: 0.8000812535425054 and parameters: {'iterations': 1921, 'learning_rate': 0.1862387912127672, 'depth': 3, 'l2_leaf_reg': 2.889224501130532, 'random_strength': 6.581745652614557, 'bagging_temperature': 1.6126565227314629, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:55,734] Trial 25 finished with value: 0.6000869702208965 and parameters: {'iterations': 1584, 'learning_rate': 0.06366732185321498, 'depth': 3, 'l2_leaf_reg': 1.5752649214659586, 'random_strength': 7.402484675935327, 'bagging_temperature': 0.9766526837510756, 'max_ctr_complexity': 1}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:58,775] Trial 26 finished with value: 0.40009019545474195 and parameters: {'iterations': 1325, 'learning_rate': 0.29855598774751346, 'depth': 2, 'l2_leaf_reg': 0.2208889657298932, 'random_strength': 5.325065672502166, 'bagging_temperature': 1.9127242100553865, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:03,094] Trial 27 finished with value: 0.6000928853628733 and parameters: {'iterations': 1334, 'learning_rate': 0.11613163730150419, 'depth': 3, 'l2_leaf_reg': 9.45123057841076, 'random_strength': 5.533799953659956, 'bagging_temperature': 1.9274030841301903, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:09,318] Trial 28 finished with value: 0.8000983637654009 and parameters: {'iterations': 1844, 'learning_rate': 0.1641911010984219, 'depth': 4, 'l2_leaf_reg': 0.35112494799611726, 'random_strength': 5.174414906779508, 'bagging_temperature': 1.8044617230564555, 'max_ctr_complexity': 1}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:12,394] Trial 29 finished with value: 0.8000839788922274 and parameters: {'iterations': 1762, 'learning_rate': 0.05685570761610727, 'depth': 2, 'l2_leaf_reg': 1.0585985105979054, 'random_strength': 3.6409464603145407, 'bagging_temperature': 1.2157697853167853, 'max_ctr_complexity': 1}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:16,041] Trial 30 finished with value: 0.8000950311155 and parameters: {'iterations': 1142, 'learning_rate': 0.028822511988424074, 'depth': 3, 'l2_leaf_reg': 0.45607309728092565, 'random_strength': 6.354246355720552, 'bagging_temperature': 1.0576620339786782, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:19,554] Trial 31 finished with value: 0.6001015512194143 and parameters: {'iterations': 1584, 'learning_rate': 0.293949338233831, 'depth': 2, 'l2_leaf_reg': 0.12958776206081657, 'random_strength': 7.672758571583746, 'bagging_temperature': 1.5675215316871607, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:22,459] Trial 32 finished with value: 0.6000885990518835 and parameters: {'iterations': 1343, 'learning_rate': 0.09313772892131732, 'depth': 2, 'l2_leaf_reg': 0.17186107402034376, 'random_strength': 5.387609363853004, 'bagging_temperature': 1.8163527391144225, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:25,739] Trial 33 finished with value: 0.6000826630005168 and parameters: {'iterations': 1555, 'learning_rate': 0.20494139135960657, 'depth': 2, 'l2_leaf_reg': 0.6202375422269499, 'random_strength': 9.910903342142007, 'bagging_temperature': 1.5127188905687525, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:28,972] Trial 34 finished with value: 0.6000816658330049 and parameters: {'iterations': 1546, 'learning_rate': 0.15009058836537717, 'depth': 2, 'l2_leaf_reg': 0.6903571305082945, 'random_strength': 9.960689784549938, 'bagging_temperature': 1.2911748812244292, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:31,865] Trial 35 finished with value: 0.6000845815284792 and parameters: {'iterations': 1321, 'learning_rate': 0.18341518325923417, 'depth': 2, 'l2_leaf_reg': 0.6829794559764659, 'random_strength': 9.29582067467878, 'bagging_temperature': 1.7022424771323652, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:34,260] Trial 36 finished with value: 0.40008800608808476 and parameters: {'iterations': 1123, 'learning_rate': 0.13506982710198825, 'depth': 2, 'l2_leaf_reg': 0.02842363480028371, 'random_strength': 9.780836425326614, 'bagging_temperature': 1.4823266830464739, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:42,852] Trial 37 finished with value: 0.800093596217913 and parameters: {'iterations': 1121, 'learning_rate': 0.12079911520304917, 'depth': 6, 'l2_leaf_reg': 0.03125584747297108, 'random_strength': 9.932834113343334, 'bagging_temperature': 1.3348092389579904, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:46,088] Trial 38 finished with value: 0.4000951138049003 and parameters: {'iterations': 935, 'learning_rate': 0.14718576685269227, 'depth': 3, 'l2_leaf_reg': 0.04373898840738072, 'random_strength': 3.111904056656519, 'bagging_temperature': 0.9146434622930857, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:49,268] Trial 39 finished with value: 0.80010652850067 and parameters: {'iterations': 960, 'learning_rate': 0.28649228773162455, 'depth': 3, 'l2_leaf_reg': 0.04512887168926569, 'random_strength': 1.8205374728573127, 'bagging_temperature': 0.8974753002660487, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:52,135] Trial 40 finished with value: 0.6001267865224457 and parameters: {'iterations': 889, 'learning_rate': 0.001315055330220757, 'depth': 3, 'l2_leaf_reg': 0.0038566286503343636, 'random_strength': 3.1448099966807757, 'bagging_temperature': 0.6518611880846126, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:54,515] Trial 41 finished with value: 0.6000939979954463 and parameters: {'iterations': 1105, 'learning_rate': 0.14102324524590748, 'depth': 2, 'l2_leaf_reg': 0.02038563270022037, 'random_strength': 4.018968124599047, 'bagging_temperature': 0.8109435440938544, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:57,191] Trial 42 finished with value: 0.8000832015925037 and parameters: {'iterations': 1247, 'learning_rate': 0.135632627127907, 'depth': 2, 'l2_leaf_reg': 0.03718955476167692, 'random_strength': 1.295059792898948, 'bagging_temperature': 1.0929279684145856, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:00,140] Trial 43 finished with value: 0.8000911964808441 and parameters: {'iterations': 1400, 'learning_rate': 0.10116792060936959, 'depth': 2, 'l2_leaf_reg': 0.21759854063826278, 'random_strength': 4.495270131345999, 'bagging_temperature': 0.9372037668376946, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:02,741] Trial 44 finished with value: 0.4000840750968095 and parameters: {'iterations': 1209, 'learning_rate': 0.05207603338369929, 'depth': 2, 'l2_leaf_reg': 0.08496706804802007, 'random_strength': 4.759580019072702, 'bagging_temperature': 1.418169384927895, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:05,385] Trial 45 finished with value: 0.8000883170501828 and parameters: {'iterations': 813, 'learning_rate': 0.05514331104631963, 'depth': 3, 'l2_leaf_reg': 0.09384002127457013, 'random_strength': 2.5237098189867107, 'bagging_temperature': 1.429178089044933, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:07,927] Trial 46 finished with value: 0.6000868383857919 and parameters: {'iterations': 1200, 'learning_rate': 0.026551837763383775, 'depth': 2, 'l2_leaf_reg': 0.01663019419480216, 'random_strength': 4.8745395563115865, 'bagging_temperature': 1.8954453436658505, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:11,611] Trial 47 finished with value: 0.6000979256899956 and parameters: {'iterations': 634, 'learning_rate': 0.04401833046577183, 'depth': 5, 'l2_leaf_reg': 0.029351081381876226, 'random_strength': 3.5905136899339194, 'bagging_temperature': 1.7087002034837566, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:14,995] Trial 48 finished with value: 0.6000863755889829 and parameters: {'iterations': 1050, 'learning_rate': 0.0757263372986644, 'depth': 3, 'l2_leaf_reg': 0.0035699696714942566, 'random_strength': 2.8310033991751915, 'bagging_temperature': 0.7718913469181943, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:17,902] Trial 49 finished with value: 0.6000867686341339 and parameters: {'iterations': 1259, 'learning_rate': 0.03079432558896357, 'depth': 2, 'l2_leaf_reg': 0.07096417054858983, 'random_strength': 4.303510429774569, 'bagging_temperature': 0.2307708218923259, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
<src.models.BaselineModel at 0x14da8bbf0>
Метрики модели
model.mean_metrics
{'max_ae': 0.39059315999444844,
'mae': 0.11484357166232506,
'num_errors_over_limit': 0.8,
'money_loss': 8.973874697678991e-05,
'target_loss': 0.8000897387469769}
forecast = model.forecast(df=train_data, taxes=taxes, holidays=holidays)
eval_df = pd.concat([
forecast.rename('y_pred'),
train_data.set_index('date')['balance'].rename('y_true')
], axis=1).dropna() # оставляем только валидный промежуток
colors = ['#F75C03', '#00CC66']
print('Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении')
plot_ts_plotly(
[eval_df['y_pred'], eval_df['y_true']],
title='Сравнение предсказания и реальности',
colors=colors, v_lines=[{'x': test_df.date.min(), 'label': 'Начало тестового промежутка'}]
)
colors = ['#F75C03', '#00CC66']
print('Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении')
plot_ts_plotly(
[(eval_df['y_pred']-eval_df['y_true']).rename('diff')],
title='Разность предсказания и реальности',
colors=colors, v_lines=[{'x': test_df.date.min(), 'label': 'Начало тестового промежутка'}]
)
Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении
Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении
Выбранные признаки
model.selected_features
['is_holiday', 'balance__lag_1', 'balance__lag_2', 'balance__lag_3', 'balance__lag_4', 'balance__lag_5', 'balance__lag_6', 'balance__lag_7', 'balance__lag_8', 'balance__lag_9', 'balance__lag_10', 'balance__lag_11', 'balance__lag_12', 'balance__lag_13', 'balance__lag_14', 'balance__lag_15', 'balance__lag_16', 'balance__lag_17', 'balance__lag_18', 'balance__lag_19', 'balance__lag_20', 'balance__lag_21', 'balance__lag_22', 'balance__lag_23', 'balance__lag_24', 'balance__lag_25', 'balance__lag_26', 'balance__lag_27', 'balance__lag_28', 'balance__lag_29', 'balance__lag_30', 'day_of_week', 'day_of_month', 'month', 'tax_type_Налог на добавленную стоимость (НДС)', 'tax_type_Сельскохозяйственным товаропроизводителям', 'tax_type_Страховые взносы', 'tax_type_Упрощенная система налогообложения (УСН)', 'tax_type_Участникам ЕГАИС и другим плательщикам акцизов', 'is_after_25_day', 'tax_type_Налог на доходы физических лиц (НДФЛ)', 'tax_type_Пользователям недр', 'tax_type_Торговый сбор', 'is_nowork', 'tax_type_Система налогообложения в виде единого налога на вмененный доход для отдельных видов деятельности (единый налог) (ЕНВД)', 'is_after_15_day', 'tax_type_Иностранным организациям', 'tax_type_Косвенные налоги']
Для определения разладок рассматривается 2 подхода: на основе значений ряда и на основе ошибок модели.
from scipy.stats import norm
from src.breakpoints_finder import BreakpointFinder
taxes = pd.read_csv(taxes_filepath)
taxes['date'] = pd.to_datetime(taxes['date'])
with open(holidays_filepath, 'rb') as f:
holidays = pickle.load(f)
rate_df = pd.read_excel(rate_filepath, engine="openpyxl")
rate_df['date'] = pd.to_datetime(rate_df['DT'])
rate_df = rate_df[['date', 'ruo']].rename(columns={'ruo': 'rate'})
rate_df = rate_df.sort_values('date').set_index('date')
data = pd.read_csv(ts_filepath)
data.columns = [col.lower() for col in data]
data['date'] = pd.to_datetime(data['date'])
for col in data.columns[1:]:
data[col] = data[col].astype(str).str.replace(',', '.').astype(float)
data = data.set_index('date')
orig_data = data.copy()
clear_data = data[data.index.year >= 2020].copy()
finder = BreakpointFinder(mean_diff=-0.01)
a = clear_data['balance'].values
for k, x_k in enumerate(a):
finder.update(x_k)
finder.count_metric()
fig, ax = plt.subplots(figsize=(15,8))
for i in range(1, len(a)):
x = [i-1, i]
y = [a[i-1], a[i]]
ax.plot(x, y, color=finder.breakpoints[i])
plt.title('Красные значения - разладки в ряде')
plt.show()
model = BaselineModel(
cross_val_split_kws = {'test_size_weeks': 1, 'train_size_weeks': 15},
hyperparams_optimizer_kws = {'optuna_n_trials': 5, 'cross_val_score_kws': {
'loss': TargetLoss (rate_df['rate']),
'additional_metrics': {'max_ae': MaxAE(), 'mae': MAE(), 'num_errors_over_limit': NumCriticalErrors(), 'money_loss': MoneyLoss (rate_df['rate'])}
}
},
feature_selector_kws={'threshold': '0.5*median'}
)
data = clear_data[['balance']].reset_index()
train_df = data[:-28].copy()
test_df = data[-28:].copy()
model.fit(df=train_df, taxes=taxes, holidays=holidays)
[I 2025-04-27 10:29:39,021] A new study created in memory with name: no-name-de61b1b6-9111-4447-bbc0-a18d32c52b21
[I 2025-04-27 10:29:48,646] Trial 0 finished with value: 17.004566319367985 and parameters: {'iterations': 1097, 'learning_rate': 0.003880943960922875, 'depth': 3, 'l2_leaf_reg': 0.429392481620831, 'random_strength': 6.661066451298646, 'bagging_temperature': 1.0260656372178987, 'max_ctr_complexity': 1}. Best is trial 0 with value: 17.004566319367985.
[I 2025-04-27 10:30:12,363] Trial 1 finished with value: 18.01038813031333 and parameters: {'iterations': 1277, 'learning_rate': 0.15803040173141628, 'depth': 6, 'l2_leaf_reg': 8.838610045672485, 'random_strength': 6.02016375349749, 'bagging_temperature': 1.1346597044835305, 'max_ctr_complexity': 3}. Best is trial 0 with value: 17.004566319367985.
[I 2025-04-27 10:30:23,112] Trial 2 finished with value: 35.30060069801129 and parameters: {'iterations': 1543, 'learning_rate': 0.0029991495914625057, 'depth': 2, 'l2_leaf_reg': 0.0001246720291611491, 'random_strength': 2.3437985102292194, 'bagging_temperature': 1.4015589882292814, 'max_ctr_complexity': 2}. Best is trial 0 with value: 17.004566319367985.
<src.models.BaselineModel at 0x7b26a3947170>
forecast = model.forecast(df=data, taxes=taxes, holidays=holidays)
eval_df = pd.concat([
forecast.rename('y_pred'),
data.set_index('date')['balance'].rename('y_true')
], axis=1).dropna() # оставляем только валидный промежуток
finder = BreakpointFinder()
a = (eval_df['y_pred']-eval_df['y_true']).values
for k, x_k in enumerate(a):
finder.update(x_k)
finder.count_metric()
fig, ax = plt.subplots(figsize=(15,8))
for i in range(1, len(a)):
x = [i-1, i]
y = [a[i-1], a[i]]
ax.plot(x, y, color=finder.breakpoints[i])
plt.title('Красные значения - разладки в ошибках')
plt.show()